iT邦幫忙

1

App 內建瀏覽器裡的 Passkey:API 存在,不代表能用

  • 分享至 

  • xImage
  •  

英文版在 dev.to。

最近幫會員登入加上 Passkey,結果在 LINE 的內建瀏覽器裡踩到一個很隱蔽的問題:按鈕有出現,按下去沒反應,而且沒有任何錯誤。

這篇文章主要就是整理一下原因,還有三件我覺得每個做 Passkey 的人都應該知道的事:怎麼判斷「真的能用」、NotAllowedError 為什麼不能直接當成使用者取消,以及後端回傳 null 為什麼會讓 Safari 直接報錯。文中的程式碼都可以直接拿去用。如果你的服務會從 LINE、Facebook、Instagram 這類 App 裡被打開,應該都用得到。

症狀:按鈕有出現,按下去卻什麼都沒發生

在 staging 測試時發現,在 LINE 裡打開登入頁,Passkey 的按鈕有出現,但按下去什麼事都沒有:沒有系統的驗證視窗,也沒有任何錯誤訊息。

因為沒有錯誤可以看,我先從螢幕錄影下手,一格一格看。結果「驗證中」的畫面只出現了一格(30fps),代表 navigator.credentials.get() 有被呼叫,而且在幾十毫秒內就失敗了。那為什麼畫面上什麼都沒有?因為程式裡把 NotAllowedError 當成「使用者自己取消」,而使用者取消本來就不該跳錯誤,所以整個失敗就這樣靜靜地被吞掉了。

接著我在實機上加了 log(非正式環境會自動開啟 eruda,可以直接在手機上看 console),在 iOS 26.6、LINE 26.12.1 上得到這組數字:

supported()  = true          // 淺層檢查過了
IUVPAA       = false (12ms)  // 平台老實說自己不支援
get()        -> NotAllowedError after 4ms

從上面可以看到,4 毫秒根本不可能是使用者取消,因為系統視窗都還沒畫出來。也就是說,LINE 的 WebView 雖然有提供 WebAuthn 的 API,但實際上不讓你用,而這是 App 和作業系統決定的,網頁這邊改什麼都沒用。

一、只檢查 API 存不存在是不夠的

很多教學會這樣判斷支不支援 Passkey:

if (window.PublicKeyCredential) {
  // 顯示 Passkey 按鈕
}

這在一般瀏覽器沒問題,但在 App 的內建瀏覽器裡就不一定了。因為 WebView 通常是直接用系統的瀏覽器核心,API 會在,但能不能真的跑驗證流程,是外層的 App 決定的。

所以比較可靠的做法是,直接問平台「你現在能不能用」。目前有兩個 API 可以問:

  • PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable():問這台裝置有沒有可以做使用者驗證的平台驗證器(Face ID、指紋、Windows Hello 這類),支援度很廣。
  • PublicKeyCredential.getClientCapabilities():WebAuthn Level 3 新加的 API,一次回傳一整組能力。Chrome / Edge 133、Firefox 135、Safari 17.4 以上才有。

而在 LINE 裡,第一個 API 會老實回答 false,所以只要用它來決定要不要顯示 Passkey,就不會再出現按了沒反應的按鈕。下面是我整理過的版本:

const PROBE_TIMEOUT_MS = 3000;
let capability: Promise<boolean> | undefined;

// 結果會快取,整個頁面只問一次
export function canUsePasskey(): Promise<boolean> {
  capability ??= probe();
  return capability;
}

async function probe(): Promise<boolean> {
  const PKC = window.PublicKeyCredential as any;
  if (!PKC || !navigator.credentials?.get) return false;

  // 新的 API:有的話優先用
  if (typeof PKC.getClientCapabilities === 'function') {
    const caps = await withTimeout(PKC.getClientCapabilities(), PROBE_TIMEOUT_MS);
    if (typeof caps?.userVerifyingPlatformAuthenticator === 'boolean') {
      return caps.userVerifyingPlatformAuthenticator;
    }
  }

  // 舊的 API:支援度比較廣
  if (typeof PKC.isUserVerifyingPlatformAuthenticatorAvailable !== 'function') {
    return false;
  }
  return (
    (await withTimeout(
      PKC.isUserVerifyingPlatformAuthenticatorAvailable(),
      PROBE_TIMEOUT_MS,
    )) === true
  );
}

// 有些受限的 WebView 回傳的 promise 永遠不會 settle,所以一定要加 timeout
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | undefined> {
  return Promise.race([
    p.catch(() => undefined),
    new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), ms)),
  ]);
}

幾個細節說明一下:

  • 一定要加 timeout。 有些受限的 WebView 回傳的 promise 永遠不會 resolve 也不會 reject,沒加 timeout 的話,整個登入頁就會卡在判斷這一步。
  • 要快取。 登入、註冊、管理 Passkey 這幾個地方都會問,快取起來只問一次就好。
  • getClientCapabilities() 的值可能是 undefined,代表瀏覽器也不確定,這時候就退回用舊的 API。我自己的專案目前只有用舊的 API,在 LINE 裡測過確定會回 false;新的 API 在各家 App 的 WebView 裡會回什麼,建議自己在實機上確認。

那為什麼不直接用 User-Agent 判斷是不是 LINE 就好?因為平台自己就會老實回答,而 UA 的規則會隨著 App 版本更新慢慢失效,哪天 LINE 開放了 Passkey,用 UA 擋掉的人也用不到。代價是只有外接安全金鑰、沒有 Touch ID 或 Windows Hello 的桌機也會被擋掉,但以手機為主的服務來說,這個取捨是可以接受的。

二、NotAllowedError 不等於使用者取消

回頭看症狀那段,錯誤之所以會被吞掉,是因為程式裡把 NotAllowedError 一律當成「使用者取消」。這個寫法其實很常見,但 NotAllowedError 涵蓋的情況比想像中多:使用者關掉視窗、等太久 timeout、沒有可用的憑證,還有這次遇到的「環境直接拒絕」,都可能是同一個錯誤。

這是 WebAuthn 規格刻意的設計。因為如果每種失敗都回不同的錯誤,網站就可以藉此推測「這個人在這台裝置上有沒有某個憑證」,在使用者沒同意的情況下識別出他是誰。所以瀏覽器不會告訴你確切的原因,只能靠其他線索判斷。

而最好用的線索就是時間。使用者要取消,至少要等系統視窗畫出來、再去按取消,不可能在幾毫秒內發生。所以我在呼叫前後計時,太快被拒絕的就當成「這個環境不能用」,提示會員改用別的方式登入:

// 人不可能在 300ms 內看到系統視窗並按下取消
const HUMAN_CANCEL_FLOOR_MS = 300;

export type PasskeyFailure = 'cancelled' | 'unavailable' | 'error';

export async function getPasskeyAssertion(options: CredentialRequestOptions) {
  const started = performance.now();
  try {
    return await navigator.credentials.get(options);
  } catch (err) {
    const elapsed = performance.now() - started;
    console.warn(`[passkey] rejected after ${Math.round(elapsed)}ms`, err);

    if (err instanceof DOMException && err.name === 'NotAllowedError') {
      const reason: PasskeyFailure =
        elapsed < HUMAN_CANCEL_FLOOR_MS ? 'unavailable' : 'cancelled';
      throw Object.assign(new Error(reason), { reason });
    }
    throw Object.assign(new Error('error'), { reason: 'error' as PasskeyFailure });
  }
}

拿到 unavailable 就顯示「這個環境不支援 Passkey,請改用簡訊登入」;拿到 cancelled 才是什麼都不說。

300ms 是怎麼來的?我實測 LINE 裡的拒絕只花了 4ms,300ms 大概是 75 倍的空間,而且還是遠低於真人取消會花的時間。不過要注意,這是經驗法則,不是規格保證的行為;而且只適用在會跳出系統視窗的流程,如果你用的是自動填入(conditional mediation)那種不跳視窗的登入,就不能這樣判斷。

理論上第一節的檢查已經會先擋掉,但萬一哪個 WebView 對自己的能力說謊,這一層至少可以確保不會再出現一個按了沒反應的按鈕。

三、後端回傳 null,Safari 直接 TypeError

同一段時間還遇到另一個 Passkey 的問題:註冊時 iOS 18 的 Safari 和 Chrome 直接丟出 TypeError,連系統視窗都還沒出來就失敗了。

原因是後端把沒設定的 WebAuthn 欄位序列化成 null,而不是直接省略,例如:

{
  "authenticatorSelection": {
    "residentKey": null,
    "userVerification": "preferred"
  }
}

對 JavaScript 來說,null 跟「沒有這個欄位」好像差不多,但瀏覽器在把參數轉成 WebAuthn 的型別時,是照 WebIDL 的規則在轉的。而有些欄位在規格上不接受 null,所以「明確寫了 null」就會在轉換這一步丟 TypeError;但如果是「沒寫」,就會直接套用預設值。

這裡解法就相對簡單,在丟給瀏覽器之前,先把 null 的欄位拿掉:

function stripNulls<T>(value: T): T {
  if (Array.isArray(value)) return value.map(stripNulls) as T;
  if (value && typeof value === 'object' && !ArrayBuffer.isView(value) && !(value instanceof ArrayBuffer)) {
    return Object.fromEntries(
      Object.entries(value)
        .filter(([, v]) => v !== null)
        .map(([k, v]) => [k, stripNulls(v)]),
    ) as T;
  }
  return value;
}

await navigator.credentials.create({ publicKey: stripNulls(creationOptions) });

當然,最根本的解法是讓後端不要輸出 null。但前端自己擋一層,就不用擔心後端哪天又多一個空欄位。

如果一定要在 LINE 裡用 Passkey

偵測到不能用之後,除了退回簡訊登入,也可以引導會員改用手機的預設瀏覽器打開。LINE 有一個網址參數 openExternalBrowser=1,帶上它的連結在 LINE 裡點開時會直接用外部瀏覽器開,例如:

https://example.com/login?openExternalBrowser=1

不過 LIFF app 不適用,而且會把會員帶離 LINE,是不是值得要看服務的情況。

在 App 內建瀏覽器裡除錯的小技巧

這次最麻煩的其實不是修,而是「看不到錯誤」。App 的內建瀏覽器沒辦法接 DevTools,所以我用了這幾個方法:

  • 在非正式環境開 eruda。它會在頁面上放一個小的 console,可以直接在手機上看 log。建議等頁面 load 之後再載入,避免影響到你想觀察的行為。
  • 先印出環境資訊。一進頁面就把 UA、origin、PublicKeyCredential 等 API 存不存在、偵測的結果全部印出來。後來整個問題就是靠這一組數字定位的。
  • 用螢幕錄影數影格。如果連 log 都還沒加,錄影後一格一格看,就能估出某個狀態停留了多久。這次就是靠「驗證中」只出現一格,才知道問題出在呼叫之後,而不是按鈕沒綁好。
  • 先加 log,再改行為。像 300ms 這個門檻,我是先讓程式印出「如果用這個門檻會怎麼判斷」,在實機上確認數字合理之後,才真的讓它決定行為。

總結

如果你的服務會在 App 內建瀏覽器裡被打開,做 Passkey 時可以對照這幾點:

  • [ ] 用 getClientCapabilities() 或 isUserVerifyingPlatformAuthenticatorAvailable() 決定要不要顯示 Passkey,而不是只看 API 存不存在
  • [ ] 偵測要加 timeout,而且快取結果
  • [ ] 不要把 NotAllowedError 一律當成使用者取消,太快被拒絕的要給會員替代方案
  • [ ] 送進 navigator.credentials 之前,先清掉 null 的欄位
  • [ ] 在真的 App 裡用實機測,桌機的模擬器測不出這些問題

參考資料:


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言